import { existsSync } from 'node:fs' import { z } from 'zod' import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors' import { sanitizeRichTextHtml } from '@open-mercato/shared/lib/html/sanitizeRichText' import { assertTier } from '../../../lib/permissions' import { loadDocumentContent } from '../../../lib/contentService' import { isDocxRenderFailedError, isDocxRenderOutputTooLargeError, isDocxRenderOverloadedError, isDocxRenderTimeoutError, renderDocxWithCapacity, } from '../../../lib/docxRenderer' import { isPdfRenderOutputTooLargeError, isPdfRenderOverloadedError, isPdfRenderTimeoutError, renderPdfWithChromium, } from '../../../lib/pdfRenderer' import { buildDocumentPdfHtml } from '../../../lib/pdfHtml' import { assertDocumentContentResourceLimits, DOCUMENTS_MAX_CONTENT_HTML_BYTES, maxBase64EncodedLength, } from '../../../lib/resourceLimits' import { DOCUMENTS_JSON_BODY_LIMITS } from '../../../lib/requestBody' import { handleDocumentsRouteError, loadScopedDocument, readBody, resolveDocumentsContext, routeErrorSchema, withDocumentsContextErrors, } from '../../_shared' type RouteContext = { params: Promise<{ id: string }> | { id: string } } type ExportFormat = 'docx' | 'pdf' export type PdfRequestDescriptor = { url: string resourceType: string isNavigationRequest: boolean } const exportQuerySchema = z.object({ format: z.enum(['docx', 'pdf']).optional(), }) const docxSnapshotSchema = z.object({ contentHtml: z.string().max(DOCUMENTS_MAX_CONTENT_HTML_BYTES), pageBreakMarker: z.string().regex(/^OM_DOCX_PAGE_BREAK_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i), }) const fileResponseSchema = z.string().describe('Binary .docx or PDF file attachment') const COMMON_CHROMIUM_PATHS = [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', ] export const DOCX_MAX_EMBEDDED_IMAGE_BYTES = 1024 * 1024 export const PDF_MAX_EMBEDDED_IMAGE_BYTES = 1024 * 1024 export const MAX_EMBEDDED_RASTER_DIMENSION = 4096 export const MAX_EMBEDDED_RASTER_PIXELS = 16 * 1024 * 1024 const DATA_IMAGE_PATTERN = /^data:image\/(png|jpeg);base64,([a-z0-9+/]+={0,2})$/i const IMAGE_TAG_PATTERN = /]*>/gi const ENTITY_REF_SPAN_PATTERN = /]*\bdata-entity-ref(?:\s|=|>))[^>]*>([\s\S]*?)<\/span>/gi const HIGHLIGHT_OPEN_PATTERN = /]*)>/gi const HIGHLIGHT_CLOSE_PATTERN = /<\/mark>/gi const LEADING_TABLE_HEADER_ROW_PATTERN = /(]*>)(\s*)(\s*)((?:(?:\s*)]*>[\s\S]*?<\/th>)+\s*<\/tr>)([\s\S]*?)<\/tbody>/gi const IMAGE_SOURCE_PATTERN = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi export const metadata = { GET: { requireAuth: true, requireFeatures: ['documents.view'] }, POST: { requireAuth: true, requireFeatures: ['documents.view'] }, } async function resolveId(context: RouteContext): Promise { const params = await context.params return params.id } function resolveExportFormat(value: string | null): ExportFormat | null { if (value === null || value === 'docx') return 'docx' if (value === 'pdf') return 'pdf' return null } function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function buildSafeFilename(title: string): string { return title.replace(/[^a-z0-9\-_. ]/gi, '_').slice(0, 120) || 'document' } type ImageSource = { index: number length: number value: string } function readSingleImageSource(tag: string): ImageSource | null { const matches = Array.from(tag.matchAll(IMAGE_SOURCE_PATTERN)) if (matches.length !== 1 || matches[0].index === undefined) return null return { index: matches[0].index, length: matches[0][0].length, value: matches[0][1] ?? matches[0][2] ?? matches[0][3] ?? '', } } function replaceImageSource(tag: string, source: ImageSource, value: string): string { return `${tag.slice(0, source.index)}src="${escapeHtml(value)}"${tag.slice(source.index + source.length)}` } function hasSafeRasterDimensions(width: number, height: number): boolean { return Number.isSafeInteger(width) && Number.isSafeInteger(height) && width > 0 && height > 0 && width <= MAX_EMBEDDED_RASTER_DIMENSION && height <= MAX_EMBEDDED_RASTER_DIMENSION && width * height <= MAX_EMBEDDED_RASTER_PIXELS } function pngCrc32(bytes: Buffer): number { let crc = 0xffffffff for (const byte of bytes) { crc ^= byte for (let bit = 0; bit < 8; bit += 1) { crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0) } } return (crc ^ 0xffffffff) >>> 0 } function readPngDimensions(bytes: Buffer): { width: number; height: number } | null { const signature = '89504e470d0a1a0a' if (bytes.length < 45 || bytes.subarray(0, 8).toString('hex') !== signature) return null let offset = 8 let dimensions: { width: number; height: number } | null = null let sawImageData = false while (offset + 12 <= bytes.length) { const length = bytes.readUInt32BE(offset) const chunkEnd = offset + 12 + length if (chunkEnd > bytes.length) return null const type = bytes.subarray(offset + 4, offset + 8) const data = bytes.subarray(offset + 8, offset + 8 + length) const expectedCrc = bytes.readUInt32BE(offset + 8 + length) if (pngCrc32(bytes.subarray(offset + 4, offset + 8 + length)) !== expectedCrc) return null const chunkType = type.toString('ascii') if (offset === 8) { if (chunkType !== 'IHDR' || length !== 13) return null const width = data.readUInt32BE(0) const height = data.readUInt32BE(4) const bitDepth = data[8] const colorType = data[9] const validBitDepth = ( (colorType === 0 && [1, 2, 4, 8, 16].includes(bitDepth!)) || (colorType === 2 && [8, 16].includes(bitDepth!)) || (colorType === 3 && [1, 2, 4, 8].includes(bitDepth!)) || (colorType === 4 && [8, 16].includes(bitDepth!)) || (colorType === 6 && [8, 16].includes(bitDepth!)) ) if ( !validBitDepth || data[10] !== 0 || data[11] !== 0 || (data[12] !== 0 && data[12] !== 1) || !hasSafeRasterDimensions(width, height) ) return null dimensions = { width, height } } else if (chunkType === 'IHDR') { return null } if (chunkType === 'IDAT') sawImageData = true if (chunkType === 'IEND') { return length === 0 && chunkEnd === bytes.length && sawImageData ? dimensions : null } offset = chunkEnd } return null } const JPEG_START_OF_FRAME_MARKERS = new Set([ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, ]) function readJpegDimensions(bytes: Buffer): { width: number; height: number } | null { if ( bytes.length < 12 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes.at(-2) !== 0xff || bytes.at(-1) !== 0xd9 ) return null let offset = 2 while (offset + 4 <= bytes.length - 2) { if (bytes[offset] !== 0xff) return null while (bytes[offset] === 0xff) offset += 1 const marker = bytes[offset] offset += 1 if (marker === undefined || marker === 0xda || marker === 0xd9) return null if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue if (offset + 2 > bytes.length - 2) return null const segmentLength = bytes.readUInt16BE(offset) if (segmentLength < 2 || offset + segmentLength > bytes.length - 2) return null if (JPEG_START_OF_FRAME_MARKERS.has(marker)) { if (segmentLength < 7) return null const height = bytes.readUInt16BE(offset + 3) const width = bytes.readUInt16BE(offset + 5) return hasSafeRasterDimensions(width, height) ? { width, height } : null } offset += segmentLength } return null } function readSafeEmbeddedImageBytes(source: string, maxDecodedBytes: number): number | null { const match = DATA_IMAGE_PATTERN.exec(source) if (!match) return null const mimeType = match[1].toLowerCase() const payload = match[2] if ( !payload || payload.length > maxBase64EncodedLength(maxDecodedBytes) || payload.length % 4 !== 0 ) return null const bytes = Buffer.from(payload, 'base64') if ( bytes.length === 0 || bytes.length > maxDecodedBytes || bytes.toString('base64') !== payload ) return null const dimensions = mimeType === 'png' ? readPngDimensions(bytes) : readJpegDimensions(bytes) if (!dimensions) return null return bytes.length } /** * Keep only bounded embedded PNG/JPEG payloads for the in-process OpenXML * renderer. Replace those small raster images with sanitizer-safe placeholders, * sanitize the full rich text, then restore the in-memory payloads. Every URL, * relative path, blob source, malformed data URI, SVG, duplicate src attribute, * and aggregate payload beyond the bound is removed before conversion, so no * exporter ever dereferences a remote image. */ function sanitizeEmbeddedRasterExportContent( contentHtml: string, maxEmbeddedBytes: number, placeholderNamespace: 'docx' | 'pdf', ): string { assertDocumentContentResourceLimits( { contentHtml }, { error: 'documents.export.inputTooLarge' }, ) const embeddedImages = new Map() let embeddedBytes = 0 const placeholderHtml = contentHtml.replace(IMAGE_TAG_PATTERN, (tag) => { const source = readSingleImageSource(tag) if (!source) return '' const remainingBytes = maxEmbeddedBytes - embeddedBytes const imageBytes = readSafeEmbeddedImageBytes(source.value, remainingBytes) if (imageBytes === null || embeddedBytes + imageBytes > maxEmbeddedBytes) return '' embeddedBytes += imageBytes const placeholder = `https://${placeholderNamespace}.invalid/__embedded-image-${embeddedImages.size}` embeddedImages.set(placeholder, source.value) return replaceImageSource(tag, source, placeholder) }) return sanitizeRichTextHtml(placeholderHtml).replace(IMAGE_TAG_PATTERN, (tag) => { const source = readSingleImageSource(tag) if (!source) return '' const embeddedSource = embeddedImages.get(source.value) return embeddedSource ? replaceImageSource(tag, source, embeddedSource) : '' }) } export function sanitizeDocxExportContent(contentHtml: string): string { return sanitizeEmbeddedRasterExportContent( contentHtml, DOCX_MAX_EMBEDDED_IMAGE_BYTES, 'docx', ) } /** * Sanitize stored rich text and bound the decoded aggregate raster payload * before authored HTML reaches Chromium. Request interception remains a * defense-in-depth boundary, not the primary content validator. */ export function sanitizePdfExportContent(contentHtml: string): string { const semanticHtml = contentHtml .replace(ENTITY_REF_SPAN_PATTERN, '$1') .replace(HIGHLIGHT_OPEN_PATTERN, (_tag, attributes: string) => ( /\bstyle\s*=/i.test(attributes) ? `` : `` )) .replace(HIGHLIGHT_CLOSE_PATTERN, '') const sanitized = sanitizeEmbeddedRasterExportContent( semanticHtml, PDF_MAX_EMBEDDED_IMAGE_BYTES, 'pdf', ) return sanitized.replace( LEADING_TABLE_HEADER_ROW_PATTERN, '$1$2$4$3$5', ) } function buildExportHtml(title: string, contentHtml: string): string { return `${escapeHtml(title)}${contentHtml}` } export function applyDocxPageBreakMarkers(contentHtml: string, marker: string): string { const markerParagraph = `

${marker}

` const breakCount = contentHtml.split(markerParagraph).length - 1 if (breakCount > 100) { throw new CrudHttpError(413, { error: 'documents.export.inputTooLarge' }) } return contentHtml.replaceAll( markerParagraph, '
', ) } function resolveChromiumExecutablePath(): string | null { const configured = process.env.DOCUMENTS_PDF_CHROMIUM_PATH?.trim() || process.env.PUPPETEER_EXECUTABLE_PATH?.trim() if (configured) return configured return COMMON_CHROMIUM_PATHS.find((path) => existsSync(path)) ?? null } function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { const arrayBuffer = new ArrayBuffer(bytes.byteLength) new Uint8Array(arrayBuffer).set(bytes) return arrayBuffer } /** * PDF rendering is an inert conversion boundary, not a browser session. Never * allow a top-level/frame navigation or network fetch from authored content. * The only request class retained is a raster image embedded entirely in the * HTML itself; current editor configuration normally strips those too. */ export function isAllowedPdfAssetRequest(request: PdfRequestDescriptor): boolean { if (request.isNavigationRequest || request.resourceType !== 'image') return false const embeddedBytes = readSafeEmbeddedImageBytes(request.url, PDF_MAX_EMBEDDED_IMAGE_BYTES) return embeddedBytes !== null && embeddedBytes <= PDF_MAX_EMBEDDED_IMAGE_BYTES } async function handleExport( request: Request, context: RouteContext, options?: { loadSnapshot?: () => Promise> operation?: string }, ): Promise { try { const format = resolveExportFormat(new URL(request.url).searchParams.get('format')) if (!format) { throw new CrudHttpError(400, { error: 'documents.export.unsupportedFormat' }) } const id = await resolveId(context) const ctx = await resolveDocumentsContext(request, ['documents.view']) await assertTier(ctx.em, id, ctx.auth, 'viewer') const doc = await loadScopedDocument(ctx, id) // The snapshot body is buffered and validated only after the caller has // proven document access, so an unauthenticated request cannot make the // server process multi-megabyte payloads. const snapshot = options?.loadSnapshot ? await options.loadSnapshot() : undefined const content = await loadDocumentContent(ctx.em, id, { tenantId: ctx.tenantId, organizationId: ctx.organizationId, }) const contentHtml = format === 'docx' && snapshot ? snapshot.contentHtml : content?.contentHtml ?? '' assertDocumentContentResourceLimits( { contentHtml, contentText: content?.contentText }, { error: 'documents.export.inputTooLarge' }, ) const safeName = buildSafeFilename(doc.title) if (format === 'docx') { const sanitized = sanitizeDocxExportContent(contentHtml) const paginated = snapshot ? applyDocxPageBreakMarkers(sanitized, snapshot.pageBreakMarker) : sanitized const docxHtml = buildExportHtml(doc.title, paginated) let bufferBytes: Uint8Array try { bufferBytes = await renderDocxWithCapacity(docxHtml) } catch (error) { if (isDocxRenderOverloadedError(error)) { throw new CrudHttpError(503, { error: 'documents.export.overloaded' }) } if (isDocxRenderTimeoutError(error)) { throw new CrudHttpError(503, { error: 'documents.export.renderTimeout' }) } if (isDocxRenderOutputTooLargeError(error)) { throw new CrudHttpError(413, { error: 'documents.export.outputTooLarge' }) } if (isDocxRenderFailedError(error)) { throw new CrudHttpError(503, { error: 'documents.export.runtimeUnavailable' }) } throw error } return new Response(toArrayBuffer(bufferBytes), { headers: { 'content-type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'content-disposition': `attachment; filename="${safeName}.docx"`, 'cache-control': 'private, no-store', }, }) } const executablePath = resolveChromiumExecutablePath() if (!executablePath) { throw new CrudHttpError(503, { error: 'documents.export.runtimeUnavailable' }) } const pdfHtml = buildDocumentPdfHtml(doc.title, sanitizePdfExportContent(contentHtml)) let pdf: Uint8Array try { pdf = await renderPdfWithChromium({ html: pdfHtml, executablePath, isAllowedRequest: (interceptedRequest) => isAllowedPdfAssetRequest({ url: interceptedRequest.url(), resourceType: interceptedRequest.resourceType(), isNavigationRequest: interceptedRequest.isNavigationRequest(), }), }) } catch (error) { if (isPdfRenderOverloadedError(error)) { throw new CrudHttpError(503, { error: 'documents.export.overloaded' }) } if (isPdfRenderTimeoutError(error)) { throw new CrudHttpError(503, { error: 'documents.export.renderTimeout' }) } if (isPdfRenderOutputTooLargeError(error)) { throw new CrudHttpError(413, { error: 'documents.export.outputTooLarge' }) } throw new CrudHttpError(503, { error: 'documents.export.runtimeUnavailable' }) } return new Response(toArrayBuffer(pdf), { headers: { 'content-type': 'application/pdf', 'content-disposition': `attachment; filename="${safeName}.pdf"`, 'cache-control': 'private, no-store', }, }) } catch (error) { return handleDocumentsRouteError(error, options?.operation ?? 'documents.export.get') } } export async function GET(request: Request, context: RouteContext): Promise { return handleExport(request, context) } export async function POST(request: Request, context: RouteContext): Promise { return handleExport(request, context, { loadSnapshot: async () => docxSnapshotSchema.parse(await readBody( request, DOCUMENTS_JSON_BODY_LIMITS.exportSnapshot, )), operation: 'documents.export.post', }) } export const openApi: OpenApiRouteDoc = withDocumentsContextErrors({ tag: 'Documents', summary: 'Document export', pathParams: z.object({ id: z.string().uuid() }), methods: { GET: { summary: 'Export document', description: 'Returns a binary .docx or PDF file attachment for a document.', query: exportQuerySchema, responses: [ { status: 200, description: 'Document export file', schema: fileResponseSchema, mediaType: 'application/octet-stream' }, ], errors: [ { status: 400, description: 'Unsupported format', schema: routeErrorSchema }, { status: 401, description: 'Unauthorized', schema: routeErrorSchema }, { status: 403, description: 'Forbidden', schema: routeErrorSchema }, { status: 404, description: 'Not found', schema: routeErrorSchema }, { status: 413, description: 'Document or generated export exceeds the safe resource bound', schema: routeErrorSchema }, { status: 503, description: 'Export runtime is unavailable, overloaded, or timed out', schema: routeErrorSchema }, ], }, POST: { summary: 'Export a paginated DOCX document', description: 'Returns a DOCX attachment using the editor pagination snapshot supplied by the client.', query: exportQuerySchema, requestBody: { contentType: 'application/json', schema: docxSnapshotSchema }, responses: [ { status: 200, description: 'Paginated DOCX export file', schema: fileResponseSchema, mediaType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, ], errors: [ { status: 400, description: 'Invalid pagination snapshot or unsupported format', schema: routeErrorSchema }, { status: 401, description: 'Unauthorized', schema: routeErrorSchema }, { status: 403, description: 'Forbidden', schema: routeErrorSchema }, { status: 404, description: 'Not found', schema: routeErrorSchema }, { status: 413, description: 'Document or generated export exceeds the safe resource bound', schema: routeErrorSchema }, { status: 503, description: 'Export runtime is unavailable, overloaded, or timed out', schema: routeErrorSchema }, ], }, }, }) export default { GET, POST }